《Day 04:launch、async、runBlocking,三種 Coroutine Builder 該選誰》 結尾留下一個約定:把這幾天分別認識的東西,放進同一個情境裡,從頭到尾完整跑一次。今天就是實現這個約定的日子。
先簡短倒帶一次,不重新解釋任何細節,只是點名。故事從一個按下下載鍵就整個凍結的畫面開始,Day 01:為什麼你需要協程,從一個會卡住主執行緒的下載函式說起 定案了全系列共用的示範情境,多來源圖片下載與聚合工具。緊接著 Day 02:第一個 suspend function,暫停到底暫停了什麼 正式定案 suspend function,建立「暫停不等於阻塞」這句話。暫停背後的黑盒子,則是在 Day 03:Continuation 是什麼,編譯器在背後偷偷做了什麼事 被打開,說明編譯器怎麼把暫停時的執行位置與變數狀態安頓好。最後一塊拼圖,協程要怎麼被真正發動起來,在 Day 04:launch、async、runBlocking,三種 Coroutine Builder 該選誰 補上。
四塊拼圖都到齊了,但它們目前還是分開放在桌上,各自被理解、各自被記住。今天的任務只有一件事:把這四塊拼圖放進同一段完整的示範程式碼裡,看看協程真正運作起來時長什麼樣子。這篇不會出現任何新名詞,讀起來應該比前四天都輕鬆,把它當成一次驗收即可。
回到多來源圖片下載與聚合工具,這次不再只看單一切片,而是把它組成一段完整的最小範例。
suspend fun downloadImage(url: String): ByteArray {
// 內部真正如何發出網路請求,前幾天已經看過語法層級的樣子
// 這裡直接視為一個已經存在、會暫停等待網路回應的 suspend function
}
fun main() = runBlocking {
val urls = listOf(
"https://example.com/photo-a.jpg",
"https://example.com/photo-b.jpg",
"https://example.com/photo-c.jpg"
)
val deferredList = urls.map { url ->
async { downloadImage(url) }
}
val allBytes = deferredList.map { it.await() }
launch {
println("本次共聚合 ${allBytes.size} 張圖片,記錄一筆下載完成事件")
}
println("聚合完成,結果已交給後續流程")
}
這段程式碼裡,四個角色各自出現了一次。downloadImage 是一個 suspend function,內部具備暫停恢復的能力。main 本身是一個普通函式,靠 runBlocking 把執行緒帶進協程世界,才有資格往下呼叫其他協程相關的東西。
進入協程世界之後,三個 async 分別對三個不同的網址發起下載,彼此不互相等待,等真正需要結果彙總的那一刻,才用 await 逐一取值,這正是暫停真實發生的時間點。
取完值之後,一個 launch 被觸發去做一件不需要回傳值的記錄動作。
至於 Continuation,它沒有出現在任何一行程式碼裡,因為它從來就不是開發者要動手操作的東西,它安靜地藏在每一個暫停點背後,替 async 跟 await 之間的等待記住該接回哪裡。
這段範例刻意停在最小可運作的樣子。它假設三個網址都能順利回應,沒有處理任何下載失敗的情況,也沒有處理如果程式提早結束、這幾個還在跑的下載該怎麼收拾。這些複雜度今天先不碰,此處只驗收階段一打下的基礎能力:讀懂、寫出一段能正確暫停、恢復、啟動的協程程式碼。
程式碼看得懂只是第一步,系列一開始就定調的核心能力,是能在腦中把它的執行時間軸還原出來。這裡逐步推演一次。
main 開始執行,runBlocking 啟動,當下的執行緒被帶進協程世界,同時這條執行緒暫時被鎖住,等裡面的內容跑完才會返回。接著三個 async 依序被呼叫,三個新的協程被啟動去各自處理一個網址的下載,這一刻它們是同時在進行的,沒有誰等誰。
程式繼續往下走到 deferredList.map { it.await() } 這一行,呼叫端才第一次真正暫停下來,把執行緒讓出去,等第一個下載真正完成,對應的 Continuation 被喚醒,接續執行去取下一個結果,直到三個結果都到齊。
這個等待與被喚醒的過程,重複發生了三次,執行緒在這幾次暫停之間可能被同一條協程收回、也可能被系統挪去做別的調度,但對寫程式碼的人來說,看到的仍然是一行接一行往下讀的樣子。
三個結果都拿到之後,launch 被呼叫,一個新的協程被送出去執行那句記錄用的 println,呼叫端不等它,直接往下走到最後一句 println("聚合完成...")。這意味著實際執行時,記錄事件那一行印出來的時間點,未必會排在聚合完成那句之前,兩者的先後順序取決於協程調度的時機,這正是 launch 「發射出去就不等你了」這個特性在真實時間軸上的樣貌。整段程式碼跑完,runBlocking 才會真正返回,把執行緒還給呼叫它的地方。
刻意把這幾個時間點攤開來看,是為了讓「暫停不等於阻塞」不再只是一句抽象定義。執行緒在 await 那一刻被讓出去,又在下載完成的那一刻被某個協程接手拿去繼續跑,中間沒有任何一段是被迫閒置死等,這跟 [[Day 01:為什麼你需要協程,從一個會卡住主執行緒的下載函式說起]] 那個畫面凍結的下載函式,是完全不同的兩種等待方式。
階段一走到這裡,建立的核心能力可以濃縮成一句話:讀懂並寫出一段能正確暫停、恢復、啟動的協程程式碼,並且能在腦中推演它的執行時間軸。這正是系列開篇時定調要打穩的第一塊地基。
但誠實地看今天這段範例,有兩個缺口。
第一,三個下載裡如果有一個逾時或失敗,await 會直接把例外往外拋,後面的聚合邏輯根本接不住,範例完全沒有準備。
第二,如果程式在下載進行到一半時需要提早結束,這三個還在跑的協程要怎麼被妥善收拾,範例裡也完全沒有交代。
這個缺口指向一個新的命題。今天的三個協程,數量少到靠人腦推演一遍還算輕鬆,但如果程式裡同時存在的協程不是三個、而是幾十個,誰該負責管理它們各自的生死,確保它們不會失控地一直跑下去,這件事顯然不能再靠每次都手動推演一遍。
把這個問題攤開來想像一下。假設同一個聚合工具往後要支援幾十個圖片來源,每個來源各自對應一個協程,使用者又可能隨時切換頁面或取消整個下載動作。靠肉眼一個一個檢查這幾十個協程目前活著還是已經結束,顯然不是一個能長期維持的做法,一定需要某種機制,把彼此相關的協程「歸在一起」,統一決定它們什麼時候該被啟動、什麼時候該一起結束。
這個機制是什麼、它怎麼運作,今天先不回答,只把問題留在這裡。下一篇會正式進入第二階段「結構化並發地基」,從協程住在哪裡、活多久這個角度切入,詳見 《Day 06:Coroutine Scope,協程住在哪裡、活多久》。